agentmux_srv\backend\storage/
agents_consolidate.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Phase 3a — one-shot backfill from `db_agent_definitions` +
5//! `db_agent_instances` into the new consolidated `db_agents` table.
6//!
7//! See `docs/specs/SPEC_AGENT_CONCEPT_CONSOLIDATION_2026_05_24.md`.
8//!
9//! Phase 3a is **write-only**: this migration populates `db_agents` so
10//! a later Phase 3b PR can flip readers over with full confidence the
11//! data is there. Phase 3c drops the old tables.
12//!
13//! Marker-file gated (`<data_dir>/migration_agents_consolidate_v1.flag`)
14//! so the backfill only runs once per data dir. Idempotent on second
15//! run (marker check short-circuits).
16//!
17//! Algorithm (per spec §"What `db_agent_instances` rows become"):
18//!
19//! 1. For each `db_agent_definitions WHERE is_seeded = 1`: INSERT a
20//!    template projection (`is_template = 1`, bindings empty).
21//!
22//! 2. For each `db_agent_definitions WHERE is_seeded = 0`: INSERT a
23//!    user-clone projection (`is_template = 0`, `parent_template_id =
24//!    parent_id`).
25//!
26//! 3. For each `db_agent_instances` row whose `definition_id` points at
27//!    a TEMPLATE: INSERT a new user-clone projection keyed by
28//!    `instance.id`, `parent_template_id = definition_id`, name +
29//!    bindings from the instance.
30//!
31//! 4. For each `db_agent_instances` row whose `definition_id` points at
32//!    an already-user-cloned definition: UPDATE the existing
33//!    user-clone projection (keyed by the definition id from pass 2) to
34//!    fold in the instance's bindings. If multiple instances point at
35//!    the same user-clone, the most-recent (`created_at` DESC) wins
36//!    and a warning is logged.
37//!
38//! Continuation rows (`parent_instance_id` non-empty) are skipped — the
39//! consolidated model has no place for them; they were the
40//! pre-Option-E continuation chain.
41
42use std::path::Path;
43
44use rusqlite::{params, Connection};
45use tracing::{info, warn};
46
47use super::error::StoreError;
48
49/// Marker filename. Lives in the data dir (one level above the `db/`
50/// subdir that holds `objects.db`).
51pub const CONSOLIDATE_MARKER: &str = "migration_agents_consolidate_v1.flag";
52
53/// Backfill statistics — useful for logs + tests.
54#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
55pub struct ConsolidateStats {
56    pub templates_inserted: usize,
57    pub user_defs_inserted: usize,
58    pub instances_as_clone_inserted: usize,
59    pub instances_folded_into_def: usize,
60    pub instances_skipped_continuation: usize,
61    pub instances_skipped_no_definition: usize,
62    pub instances_collision_warned: usize,
63    pub already_done: bool,
64}
65
66/// Run the one-shot consolidation backfill, gated by the marker file.
67///
68/// `data_dir` is the directory that holds the marker file (typically
69/// the parent of the `db/` directory). Pass `None` to skip marker
70/// gating — only intended for tests + in-memory stores.
71///
72/// Returns `Ok(stats)` on success (incl. the marker-already-present
73/// short-circuit). Failures roll back the active transaction and
74/// return the underlying SQLite error; the marker is NOT written.
75pub fn run_consolidate_migration(
76    conn: &mut Connection,
77    data_dir: Option<&Path>,
78) -> Result<ConsolidateStats, StoreError> {
79    // Marker gate.
80    if let Some(dir) = data_dir {
81        let marker = dir.join(CONSOLIDATE_MARKER);
82        if marker.exists() {
83            return Ok(ConsolidateStats {
84                already_done: true,
85                ..Default::default()
86            });
87        }
88    }
89
90    // The backfill runs inside a transaction so a mid-flight failure
91    // leaves db_agents empty rather than half-populated. The dual-
92    // write call sites tolerate empty + idempotently upsert on the
93    // next mutation, so partial backfill state is recoverable.
94    let tx = conn.transaction()?;
95
96    let mut stats = ConsolidateStats::default();
97
98    // Pass 1 + 2 — definitions.
99    {
100        let mut def_stmt = tx.prepare(
101            "SELECT id, name, icon, provider, description,
102                    working_directory, shell, provider_flags, auto_start,
103                    restart_on_crash, idle_timeout_minutes, created_at,
104                    agent_type, environment, agent_bus_id, is_seeded,
105                    accounts, parent_id, branch_label, updated_at, slug
106             FROM db_agent_definitions",
107        )?;
108        let rows = def_stmt.query_map([], |row| {
109            Ok(DefRow {
110                id: row.get(0)?,
111                name: row.get(1)?,
112                icon: row.get(2)?,
113                provider: row.get(3)?,
114                description: row.get(4)?,
115                working_directory: row.get(5)?,
116                shell: row.get(6)?,
117                provider_flags: row.get(7)?,
118                auto_start: row.get(8)?,
119                restart_on_crash: row.get(9)?,
120                idle_timeout_minutes: row.get(10)?,
121                created_at: row.get(11)?,
122                agent_type: row.get(12)?,
123                environment: row.get(13)?,
124                agent_bus_id: row.get(14)?,
125                is_seeded: row.get(15)?,
126                accounts: row.get(16)?,
127                parent_id: row.get(17)?,
128                branch_label: row.get(18)?,
129                updated_at: row.get(19)?,
130                slug: row.get(20)?,
131            })
132        })?;
133        let mut defs: Vec<DefRow> = Vec::new();
134        for r in rows {
135            defs.push(r?);
136        }
137        drop(def_stmt);
138        for def in &defs {
139            let is_template = if def.is_seeded == 1 { 1_i64 } else { 0_i64 };
140            let parent_template_id = if def.is_seeded == 1 {
141                String::new()
142            } else {
143                def.parent_id.clone()
144            };
145            // Use INSERT OR REPLACE so a re-run after a partial state
146            // (e.g. a developer deleted the marker manually) doesn't
147            // explode on the PK; the user-clone-binding-fold path
148            // immediately below relies on definition rows existing.
149            tx.execute(
150                "INSERT OR REPLACE INTO db_agents (
151                    id, name, icon, description,
152                    is_template, parent_template_id,
153                    provider, provider_flags, shell, environment,
154                    agent_type, agent_bus_id, accounts,
155                    auto_start, restart_on_crash, idle_timeout_minutes,
156                    slug, branch_label,
157                    identity_id, memory_id, working_directory, github_context,
158                    instance_name,
159                    created_at, updated_at, is_seeded, user_hidden
160                 ) VALUES (
161                    ?1, ?2, ?3, ?4,
162                    ?5, ?6,
163                    ?7, ?8, ?9, ?10,
164                    ?11, ?12, ?13,
165                    ?14, ?15, ?16,
166                    ?17, ?18,
167                    '', '', '', '',
168                    '',
169                    ?19, ?20, ?21, 0
170                 )",
171                params![
172                    def.id,
173                    def.name,
174                    def.icon,
175                    def.description,
176                    is_template,
177                    parent_template_id,
178                    def.provider,
179                    def.provider_flags,
180                    def.shell,
181                    def.environment,
182                    def.agent_type,
183                    def.agent_bus_id,
184                    def.accounts,
185                    def.auto_start,
186                    def.restart_on_crash,
187                    def.idle_timeout_minutes,
188                    def.slug,
189                    def.branch_label,
190                    def.created_at,
191                    def.updated_at,
192                    def.is_seeded,
193                ],
194            )?;
195            if def.is_seeded == 1 {
196                stats.templates_inserted += 1;
197            } else {
198                stats.user_defs_inserted += 1;
199            }
200        }
201    }
202
203    // Pass 3 + 4 — instances.
204    // Order by created_at DESC so the FIRST instance we see for a
205    // given user-cloned def is the most recent (the spec wants
206    // most-recent bindings to win on collision).
207    let inst_rows: Vec<InstanceRow> = {
208        let mut stmt = tx.prepare(
209            "SELECT i.id, i.definition_id, i.parent_instance_id,
210                    i.instance_name, i.identity_id, i.memory_id,
211                    i.working_directory, i.github_context,
212                    i.created_at, i.display_hidden,
213                    d.is_seeded, d.name, d.icon, d.description,
214                    d.provider, d.provider_flags, d.shell, d.environment,
215                    d.agent_type, d.agent_bus_id, d.accounts,
216                    d.auto_start, d.restart_on_crash, d.idle_timeout_minutes,
217                    d.slug, d.branch_label
218             FROM db_agent_instances i
219             LEFT JOIN db_agent_definitions d ON d.id = i.definition_id
220             ORDER BY i.created_at DESC",
221        )?;
222        let iter = stmt.query_map([], |row| {
223            Ok(InstanceRow {
224                id: row.get(0)?,
225                definition_id: row.get(1)?,
226                parent_instance_id: row.get(2)?,
227                instance_name: row.get(3)?,
228                identity_id: row.get(4)?,
229                memory_id: row.get(5)?,
230                working_directory: row.get(6)?,
231                github_context: row.get(7)?,
232                created_at: row.get(8)?,
233                display_hidden: row.get::<_, i64>(9)? != 0,
234                def_is_seeded: row.get::<_, Option<i64>>(10)?.unwrap_or(0),
235                def_name: row.get::<_, Option<String>>(11)?.unwrap_or_default(),
236                def_icon: row.get::<_, Option<String>>(12)?.unwrap_or_default(),
237                def_description: row.get::<_, Option<String>>(13)?.unwrap_or_default(),
238                def_provider: row.get::<_, Option<String>>(14)?.unwrap_or_default(),
239                def_provider_flags: row.get::<_, Option<String>>(15)?.unwrap_or_default(),
240                def_shell: row.get::<_, Option<String>>(16)?.unwrap_or_default(),
241                def_environment: row.get::<_, Option<String>>(17)?.unwrap_or_default(),
242                def_agent_type: row
243                    .get::<_, Option<String>>(18)?
244                    .unwrap_or_else(|| "standalone".to_string()),
245                def_agent_bus_id: row.get::<_, Option<String>>(19)?.unwrap_or_default(),
246                def_accounts: row.get::<_, Option<String>>(20)?.unwrap_or_default(),
247                def_auto_start: row.get::<_, Option<i64>>(21)?.unwrap_or(0),
248                def_restart_on_crash: row.get::<_, Option<i64>>(22)?.unwrap_or(0),
249                def_idle_timeout_minutes: row.get::<_, Option<i64>>(23)?.unwrap_or(0),
250                def_slug: row.get::<_, Option<String>>(24)?.unwrap_or_default(),
251                def_branch_label: row.get::<_, Option<String>>(25)?.unwrap_or_default(),
252                def_present: row.get::<_, Option<i64>>(10)?.is_some(),
253            })
254        })?;
255        let mut out = Vec::new();
256        for r in iter {
257            out.push(r?);
258        }
259        out
260    };
261
262    // Track which user-clone def-ids already had their bindings folded.
263    // Multiple instances on the same user-clone-def → first wins (most
264    // recent because we ordered DESC); the rest get a warning.
265    let mut folded: std::collections::HashSet<String> = std::collections::HashSet::new();
266
267    for inst in &inst_rows {
268        if !inst.parent_instance_id.is_empty() {
269            stats.instances_skipped_continuation += 1;
270            continue;
271        }
272        if !inst.def_present {
273            // Orphaned instance — no definition row. The old schema's
274            // FK cascade would have removed this; if it survived,
275            // there's nothing to project against.
276            stats.instances_skipped_no_definition += 1;
277            warn!(
278                instance_id = %inst.id,
279                definition_id = %inst.definition_id,
280                "agents_consolidate: instance has no definition; skipping",
281            );
282            continue;
283        }
284        if inst.def_is_seeded == 1 {
285            // Instance of a TEMPLATE — INSERT a new user-clone row
286            // keyed by the instance id.
287            let name = if inst.instance_name.is_empty() {
288                inst.def_name.clone()
289            } else {
290                inst.instance_name.clone()
291            };
292            tx.execute(
293                "INSERT OR REPLACE INTO db_agents (
294                    id, name, icon, description,
295                    is_template, parent_template_id,
296                    provider, provider_flags, shell, environment,
297                    agent_type, agent_bus_id, accounts,
298                    auto_start, restart_on_crash, idle_timeout_minutes,
299                    slug, branch_label,
300                    identity_id, memory_id, working_directory, github_context,
301                    instance_name,
302                    created_at, updated_at, is_seeded, user_hidden
303                 ) VALUES (
304                    ?1, ?2, ?3, ?4,
305                    0, ?5,
306                    ?6, ?7, ?8, ?9,
307                    ?10, ?11, ?12,
308                    ?13, ?14, ?15,
309                    ?16, ?17,
310                    ?18, ?19, ?20, ?21,
311                    ?22,
312                    ?23, ?23, 0, ?24
313                 )",
314                params![
315                    inst.id,
316                    name,
317                    inst.def_icon,
318                    inst.def_description,
319                    inst.definition_id,
320                    inst.def_provider,
321                    inst.def_provider_flags,
322                    inst.def_shell,
323                    inst.def_environment,
324                    inst.def_agent_type,
325                    inst.def_agent_bus_id,
326                    inst.def_accounts,
327                    inst.def_auto_start,
328                    inst.def_restart_on_crash,
329                    inst.def_idle_timeout_minutes,
330                    inst.def_slug,
331                    inst.def_branch_label,
332                    inst.identity_id,
333                    inst.memory_id,
334                    inst.working_directory,
335                    inst.github_context,
336                    inst.instance_name,
337                    inst.created_at,
338                    if inst.display_hidden { 1_i64 } else { 0_i64 },
339                ],
340            )?;
341            stats.instances_as_clone_inserted += 1;
342        } else {
343            // Instance of an already-user-cloned definition — UPDATE
344            // the existing user-clone row (keyed by definition_id)
345            // to fold in the instance bindings. Collision: only the
346            // first (most recent) wins; warn on subsequent.
347            if folded.contains(&inst.definition_id) {
348                stats.instances_collision_warned += 1;
349                warn!(
350                    instance_id = %inst.id,
351                    definition_id = %inst.definition_id,
352                    "agents_consolidate: multiple instances on one user-cloned def; keeping most-recent bindings",
353                );
354                continue;
355            }
356            let name = if inst.instance_name.is_empty() {
357                inst.def_name.clone()
358            } else {
359                inst.instance_name.clone()
360            };
361            // Stamp `updated_at` with the folded instance's
362            // `created_at` (its launch moment). Without this, the
363            // backfill UPDATE leaves `updated_at` as whatever the
364            // def's edit time was — which makes `db_agents.updated_at`
365            // useless as a "most-recently-used" sort key for migrated
366            // stores, breaking the ordering invariant that the live
367            // dual-write (`agents_dual_write_instance_insert`) maintains
368            // for new launches. Codex P2 on PR #1110 — surfaced via the
369            // first read-flip (`instance_get_by_name`) ordering by
370            // `updated_at DESC`.
371            //
372            // The loop iterates `ORDER BY i.created_at DESC`, so the
373            // FIRST instance per def_id is the most recent, and that's
374            // the one whose `created_at` we want imprinted as
375            // `updated_at`. Collision warns skip subsequent rows for
376            // the same def.
377            tx.execute(
378                "UPDATE db_agents SET
379                    name = ?1,
380                    identity_id = ?2,
381                    memory_id = ?3,
382                    working_directory = ?4,
383                    github_context = ?5,
384                    instance_name = ?6,
385                    user_hidden = ?7,
386                    updated_at = MAX(updated_at, ?8)
387                 WHERE id = ?9 AND is_template = 0",
388                params![
389                    name,
390                    inst.identity_id,
391                    inst.memory_id,
392                    inst.working_directory,
393                    inst.github_context,
394                    inst.instance_name,
395                    if inst.display_hidden { 1_i64 } else { 0_i64 },
396                    inst.created_at,
397                    inst.definition_id,
398                ],
399            )?;
400            folded.insert(inst.definition_id.clone());
401            stats.instances_folded_into_def += 1;
402        }
403    }
404
405    tx.commit()?;
406
407    // Marker written AFTER successful commit so a crash mid-backfill
408    // leaves the marker absent → next start retries from scratch.
409    if let Some(dir) = data_dir {
410        let marker = dir.join(CONSOLIDATE_MARKER);
411        if let Err(e) = std::fs::write(&marker, b"phase3a") {
412            // The data is in place; failing to write the marker just
413            // means the next startup will redo the work. Log + return
414            // success.
415            warn!(
416                error = %e,
417                marker = %marker.display(),
418                "agents_consolidate: failed to write marker; next startup will redo backfill",
419            );
420        }
421    }
422
423    info!(
424        templates_inserted = stats.templates_inserted,
425        user_defs_inserted = stats.user_defs_inserted,
426        instances_as_clone_inserted = stats.instances_as_clone_inserted,
427        instances_folded_into_def = stats.instances_folded_into_def,
428        instances_skipped_continuation = stats.instances_skipped_continuation,
429        instances_skipped_no_definition = stats.instances_skipped_no_definition,
430        instances_collision_warned = stats.instances_collision_warned,
431        "agents_consolidate: backfill completed",
432    );
433    Ok(stats)
434}
435
436/// Delta repair: backfill any `db_agent_definitions` rows that are
437/// missing from `db_agents`.
438///
439/// This closes a gap the one-shot consolidation migration cannot cover:
440/// agents defined after the marker file was written (and before Phase 3b
441/// dual-write landed) live only in `db_agent_definitions`.  Phase 3b
442/// readers look exclusively at `db_agents`, so those agents are invisible
443/// — no icon, no reattach, "clicking does nothing".
444///
445/// Unlike `run_consolidate_migration`, this is **not** marker-gated.  It
446/// runs on every startup (cheap — one indexed LEFT JOIN + a handful of
447/// inserts at most) and is idempotent via `INSERT OR IGNORE`.
448///
449/// Returns the number of definitions inserted.
450pub fn repair_def_gaps(conn: &mut Connection) -> Result<usize, StoreError> {
451    // Find every db_agent_definitions row that has no matching id in
452    // db_agents.  These were written between Phase 3a marker creation and
453    // Phase 3b dual-write landing.
454    let mut stmt = conn.prepare(
455        "SELECT d.id, d.name, d.icon, d.provider, d.description,
456                d.working_directory, d.shell, d.provider_flags, d.auto_start,
457                d.restart_on_crash, d.idle_timeout_minutes, d.created_at,
458                d.agent_type, d.environment, d.agent_bus_id, d.is_seeded,
459                d.accounts, d.parent_id, d.branch_label, d.updated_at,
460                d.slug, d.user_hidden
461         FROM db_agent_definitions d
462         LEFT JOIN db_agents a ON a.id = d.id
463         WHERE a.id IS NULL",
464    )?;
465
466    #[allow(clippy::type_complexity)]
467    let missing: Vec<(
468        String, String, String, String, String, String, String, String,
469        i64, i64, i64, i64, String, String, String, i64, String, String,
470        String, i64, String, i64,
471    )> = stmt
472        .query_map([], |row| {
473            Ok((
474                row.get::<_, String>(0)?,   // id
475                row.get::<_, String>(1)?,   // name
476                row.get::<_, String>(2)?,   // icon
477                row.get::<_, String>(3)?,   // provider
478                row.get::<_, String>(4)?,   // description
479                row.get::<_, String>(5)?,   // working_directory
480                row.get::<_, String>(6)?,   // shell
481                row.get::<_, String>(7)?,   // provider_flags
482                row.get::<_, i64>(8)?,      // auto_start
483                row.get::<_, i64>(9)?,      // restart_on_crash
484                row.get::<_, i64>(10)?,     // idle_timeout_minutes
485                row.get::<_, i64>(11)?,     // created_at
486                row.get::<_, String>(12)?,  // agent_type
487                row.get::<_, String>(13)?,  // environment
488                row.get::<_, String>(14)?,  // agent_bus_id
489                row.get::<_, i64>(15)?,     // is_seeded
490                row.get::<_, String>(16)?,  // accounts
491                row.get::<_, String>(17)?,  // parent_id
492                row.get::<_, String>(18)?,  // branch_label
493                row.get::<_, i64>(19)?,     // updated_at
494                row.get::<_, String>(20)?,  // slug
495                row.get::<_, i64>(21)?,     // user_hidden
496            ))
497        })?
498        .collect::<Result<Vec<_>, _>>()?;
499    drop(stmt);
500
501    if missing.is_empty() {
502        return Ok(0);
503    }
504
505    let tx = conn.transaction()?;
506    let mut inserted = 0usize;
507    for (
508        id, name, icon, provider, description, working_directory, shell,
509        provider_flags, auto_start, restart_on_crash, idle_timeout_minutes,
510        created_at, agent_type, environment, agent_bus_id, is_seeded,
511        accounts, parent_id, branch_label, updated_at, slug, user_hidden,
512    ) in &missing
513    {
514        let is_template = if *is_seeded == 1 { 1_i64 } else { 0_i64 };
515        let parent_template_id = if *is_seeded == 1 {
516            String::new()
517        } else {
518            parent_id.clone()
519        };
520        let affected = tx.execute(
521            "INSERT OR IGNORE INTO db_agents (
522                id, name, icon, description,
523                is_template, parent_template_id,
524                provider, provider_flags, shell, environment,
525                agent_type, agent_bus_id, accounts,
526                auto_start, restart_on_crash, idle_timeout_minutes,
527                slug, branch_label,
528                identity_id, memory_id, working_directory, github_context,
529                instance_name,
530                created_at, updated_at, is_seeded, user_hidden
531             ) VALUES (
532                ?1, ?2, ?3, ?4,
533                ?5, ?6,
534                ?7, ?8, ?9, ?10,
535                ?11, ?12, ?13,
536                ?14, ?15, ?16,
537                ?17, ?18,
538                '', '', ?19, '',
539                '',
540                ?20, ?21, ?22, ?23
541             )",
542            params![
543                id, name, icon, description,
544                is_template, parent_template_id,
545                provider, provider_flags, shell, environment,
546                agent_type, agent_bus_id, accounts,
547                auto_start, restart_on_crash, idle_timeout_minutes,
548                slug, branch_label,
549                working_directory,
550                created_at, updated_at, is_seeded, user_hidden,
551            ],
552        )?;
553        if affected > 0 {
554            warn!(
555                def_id = %id,
556                name = %name,
557                "agents_consolidate: gap-repair inserted missing definition into db_agents"
558            );
559            inserted += 1;
560        }
561    }
562    tx.commit()?;
563
564    if inserted > 0 {
565        info!(
566            count = inserted,
567            "agents_consolidate: gap-repair complete — definitions backfilled"
568        );
569    }
570
571    Ok(inserted)
572}
573
574/// Snapshot of one `db_agent_definitions` row, narrow projection
575/// matching what the backfill needs.
576struct DefRow {
577    id: String,
578    name: String,
579    icon: String,
580    provider: String,
581    description: String,
582    working_directory: String,
583    shell: String,
584    provider_flags: String,
585    auto_start: i64,
586    restart_on_crash: i64,
587    idle_timeout_minutes: i64,
588    created_at: i64,
589    agent_type: String,
590    environment: String,
591    agent_bus_id: String,
592    is_seeded: i64,
593    accounts: String,
594    parent_id: String,
595    branch_label: String,
596    updated_at: i64,
597    slug: String,
598}
599
600/// Snapshot of one `db_agent_instances` row plus the LEFT-JOINed
601/// definition fields the backfill copies into `db_agents`.
602struct InstanceRow {
603    id: String,
604    definition_id: String,
605    parent_instance_id: String,
606    instance_name: String,
607    identity_id: String,
608    memory_id: String,
609    working_directory: String,
610    github_context: String,
611    created_at: i64,
612    display_hidden: bool,
613    def_present: bool,
614    def_is_seeded: i64,
615    def_name: String,
616    def_icon: String,
617    def_description: String,
618    def_provider: String,
619    def_provider_flags: String,
620    def_shell: String,
621    def_environment: String,
622    def_agent_type: String,
623    def_agent_bus_id: String,
624    def_accounts: String,
625    def_auto_start: i64,
626    def_restart_on_crash: i64,
627    def_idle_timeout_minutes: i64,
628    def_slug: String,
629    def_branch_label: String,
630}
631
632#[cfg(test)]
633mod tests {
634    use super::*;
635    use crate::backend::storage::migrations::run_object_schema;
636
637    fn fresh_conn() -> Connection {
638        let conn = Connection::open_in_memory().unwrap();
639        conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
640        run_object_schema(&conn).unwrap();
641        conn
642    }
643
644    fn insert_def(
645        conn: &Connection,
646        id: &str,
647        name: &str,
648        is_seeded: i64,
649        parent_id: &str,
650    ) {
651        conn.execute(
652            "INSERT INTO db_agent_definitions
653                (id, slug, name, icon, provider, description, working_directory, shell,
654                 provider_flags, auto_start, restart_on_crash, idle_timeout_minutes,
655                 created_at, agent_type, environment, agent_bus_id, is_seeded, accounts,
656                 parent_id, branch_label, updated_at)
657             VALUES (?1, ?2, ?3, '✦', 'claude', 'desc', '', 'bash',
658                     '', 0, 0, 0,
659                     ?4, 'standalone', '', '', ?5, '',
660                     ?6, '', ?4)",
661            params![id, id, name, 1000_i64, is_seeded, parent_id],
662        )
663        .unwrap();
664    }
665
666    fn insert_instance(
667        conn: &Connection,
668        id: &str,
669        definition_id: &str,
670        instance_name: &str,
671        identity_id: &str,
672        memory_id: &str,
673        working_directory: &str,
674        created_at: i64,
675        display_hidden: bool,
676    ) {
677        conn.execute(
678            "INSERT INTO db_agent_instances
679                (id, definition_id, parent_instance_id, block_id, session_id, status,
680                 github_context, started_at, ended_at, created_at, identity_id, memory_id,
681                 instance_name, working_directory, display_hidden)
682             VALUES (?1, ?2, '', '', '', 'running', '', ?3, 0, ?3, ?4, ?5, ?6, ?7, ?8)",
683            params![
684                id,
685                definition_id,
686                created_at,
687                identity_id,
688                memory_id,
689                instance_name,
690                working_directory,
691                if display_hidden { 1_i64 } else { 0_i64 },
692            ],
693        )
694        .unwrap();
695    }
696
697    fn count_agents(conn: &Connection, where_clause: &str) -> i64 {
698        let sql = format!("SELECT COUNT(*) FROM db_agents WHERE {where_clause}");
699        conn.query_row(&sql, [], |row| row.get(0)).unwrap()
700    }
701
702    #[test]
703    fn round_trip_template_user_clone_and_instance_lifecycle() {
704        let mut conn = fresh_conn();
705
706        // 2 templates, 1 user-cloned definition (from template tpl-1),
707        // 3 instances:
708        //   - inst-A on template tpl-1 (named "Maks")
709        //   - inst-B on template tpl-2 (unnamed)
710        //   - inst-C on user-clone-def def-u1 (named "Custom")
711        insert_def(&conn, "tpl-1", "Coder", 1, "");
712        insert_def(&conn, "tpl-2", "Reviewer", 1, "");
713        insert_def(&conn, "def-u1", "Maks-the-Coder", 0, "tpl-1");
714        insert_instance(&conn, "inst-A", "tpl-1", "Maks", "id-1", "mem-1", "/wd/a", 100, false);
715        insert_instance(&conn, "inst-B", "tpl-2", "", "", "", "/wd/b", 200, false);
716        insert_instance(&conn, "inst-C", "def-u1", "Custom", "id-2", "mem-2", "/wd/c", 300, false);
717
718        let stats = run_consolidate_migration(&mut conn, None).unwrap();
719        assert_eq!(stats.templates_inserted, 2);
720        assert_eq!(stats.user_defs_inserted, 1);
721        assert_eq!(stats.instances_as_clone_inserted, 2);
722        assert_eq!(stats.instances_folded_into_def, 1);
723        assert_eq!(stats.instances_skipped_continuation, 0);
724
725        // Templates present, is_template=1, parent_template_id empty.
726        assert_eq!(count_agents(&conn, "is_template = 1"), 2);
727        for tpl in &["tpl-1", "tpl-2"] {
728            let parent: String = conn
729                .query_row(
730                    "SELECT parent_template_id FROM db_agents WHERE id = ?1",
731                    params![tpl],
732                    |r| r.get(0),
733                )
734                .unwrap();
735            assert_eq!(parent, "");
736        }
737
738        // 3 user-clone rows: def-u1 (folded from inst-C), inst-A, inst-B.
739        assert_eq!(count_agents(&conn, "is_template = 0"), 3);
740
741        // inst-A → parent_template_id = tpl-1, name = "Maks".
742        let (parent, name, identity): (String, String, String) = conn
743            .query_row(
744                "SELECT parent_template_id, name, identity_id FROM db_agents WHERE id = 'inst-A'",
745                [],
746                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
747            )
748            .unwrap();
749        assert_eq!(parent, "tpl-1");
750        assert_eq!(name, "Maks");
751        assert_eq!(identity, "id-1");
752
753        // inst-B (unnamed) → name falls back to template name "Reviewer".
754        let name: String = conn
755            .query_row(
756                "SELECT name FROM db_agents WHERE id = 'inst-B'",
757                [],
758                |r| r.get(0),
759            )
760            .unwrap();
761        assert_eq!(name, "Reviewer");
762
763        // def-u1 (user-clone) folded inst-C's bindings.
764        let (name, identity, memory): (String, String, String) = conn
765            .query_row(
766                "SELECT name, identity_id, memory_id FROM db_agents WHERE id = 'def-u1'",
767                [],
768                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
769            )
770            .unwrap();
771        assert_eq!(name, "Custom"); // instance_name overrode def name
772        assert_eq!(identity, "id-2");
773        assert_eq!(memory, "mem-2");
774    }
775
776    #[test]
777    fn multiple_instances_on_one_user_clone_keeps_most_recent_bindings() {
778        let mut conn = fresh_conn();
779        insert_def(&conn, "tpl-1", "Coder", 1, "");
780        insert_def(&conn, "def-u1", "User Coder", 0, "tpl-1");
781        // Two instances on def-u1; most recent has id-RECENT.
782        insert_instance(&conn, "inst-old", "def-u1", "Old", "id-OLD", "mem-OLD", "/wd/old", 100, false);
783        insert_instance(&conn, "inst-new", "def-u1", "New", "id-RECENT", "mem-RECENT", "/wd/new", 999, false);
784
785        let stats = run_consolidate_migration(&mut conn, None).unwrap();
786        assert_eq!(stats.instances_collision_warned, 1);
787
788        let identity: String = conn
789            .query_row(
790                "SELECT identity_id FROM db_agents WHERE id = 'def-u1'",
791                [],
792                |r| r.get(0),
793            )
794            .unwrap();
795        assert_eq!(identity, "id-RECENT", "most-recent instance's bindings must win");
796    }
797
798    #[test]
799    fn fold_into_user_clone_stamps_updated_at_from_instance_created_at() {
800        // Codex P2 on PR #1110: ordering `db_agents` by `updated_at`
801        // would misbehave on migrated stores if the fold UPDATE
802        // didn't write `updated_at`. After the fix, the folded
803        // user-clone row's `updated_at` equals the most-recent
804        // instance's `created_at` (the launch moment), not the def's
805        // edit time — matching what the live dual-write
806        // (`agents_dual_write_instance_insert`) stamps for new
807        // launches.
808        let mut conn = fresh_conn();
809        // Def created at t=1000 (insert_def uses ?4 for both
810        // created_at and updated_at).
811        insert_def(&conn, "tpl-1", "Coder", 1, "");
812        insert_def(&conn, "def-u1", "User Coder", 0, "tpl-1");
813        // Instance launched at t=5000 — later than the def.
814        insert_instance(
815            &conn, "inst-1", "def-u1", "Maks", "id-1", "mem-1", "/wd/m", 5000, false,
816        );
817
818        run_consolidate_migration(&mut conn, None).unwrap();
819
820        let updated_at: i64 = conn
821            .query_row(
822                "SELECT updated_at FROM db_agents WHERE id = 'def-u1'",
823                [],
824                |r| r.get(0),
825            )
826            .unwrap();
827        assert_eq!(
828            updated_at, 5000,
829            "folded user-clone row's updated_at must reflect the instance's launch time, \
830             not the def's edit time, so ORDER BY updated_at picks the right row"
831        );
832    }
833
834    #[test]
835    fn fold_into_user_clone_keeps_higher_updated_at() {
836        // MAX(updated_at, ?) semantics: if the def was edited AFTER
837        // the instance launched, the def's edit time should stay as
838        // updated_at (the def edit IS the most-recent touch).
839        let mut conn = fresh_conn();
840        insert_def(&conn, "tpl-1", "Coder", 1, "");
841        // Def edited at t=8000.
842        conn.execute(
843            "UPDATE db_agent_definitions SET updated_at = 8000 WHERE id = 'tpl-1'",
844            [],
845        )
846        .unwrap();
847        insert_def(&conn, "def-u1", "User Coder", 0, "tpl-1");
848        conn.execute(
849            "UPDATE db_agent_definitions SET updated_at = 8000 WHERE id = 'def-u1'",
850            [],
851        )
852        .unwrap();
853        // Now insert an OLDER instance.
854        insert_instance(
855            &conn, "inst-1", "def-u1", "Maks", "id-1", "mem-1", "/wd/m", 3000, false,
856        );
857
858        run_consolidate_migration(&mut conn, None).unwrap();
859
860        let updated_at: i64 = conn
861            .query_row(
862                "SELECT updated_at FROM db_agents WHERE id = 'def-u1'",
863                [],
864                |r| r.get(0),
865            )
866            .unwrap();
867        assert_eq!(
868            updated_at, 8000,
869            "later def edit beats older instance launch — MAX(updated_at, inst.created_at)"
870        );
871    }
872
873    #[test]
874    fn marker_short_circuits_second_run() {
875        let mut conn = fresh_conn();
876        insert_def(&conn, "tpl-1", "Coder", 1, "");
877        let tmp = tempfile::tempdir().unwrap();
878
879        let stats1 = run_consolidate_migration(&mut conn, Some(tmp.path())).unwrap();
880        assert_eq!(stats1.templates_inserted, 1);
881        assert!(!stats1.already_done);
882        assert!(tmp.path().join(CONSOLIDATE_MARKER).exists());
883
884        // Insert a NEW row after the marker; second call must NOT see
885        // it.
886        insert_def(&conn, "tpl-2", "Reviewer", 1, "");
887        let stats2 = run_consolidate_migration(&mut conn, Some(tmp.path())).unwrap();
888        assert!(stats2.already_done);
889        assert_eq!(stats2.templates_inserted, 0);
890        // db_agents only has tpl-1 — the post-marker insert is the
891        // dual-write hook's problem, not the backfill's.
892        assert_eq!(count_agents(&conn, "is_template = 1"), 1);
893    }
894
895    #[test]
896    fn skips_continuation_rows() {
897        let mut conn = fresh_conn();
898        insert_def(&conn, "tpl-1", "Coder", 1, "");
899        insert_instance(&conn, "inst-A", "tpl-1", "Original", "", "", "/wd/a", 100, false);
900        // Continuation row (parent_instance_id = inst-A).
901        conn.execute(
902            "INSERT INTO db_agent_instances
903                (id, definition_id, parent_instance_id, block_id, session_id, status,
904                 github_context, started_at, ended_at, created_at, identity_id, memory_id,
905                 instance_name, working_directory, display_hidden)
906             VALUES ('inst-cont', 'tpl-1', 'inst-A', '', '', 'running', '', 200, 0, 200,
907                     '', '', 'Original', '/wd/a', 0)",
908            [],
909        )
910        .unwrap();
911
912        let stats = run_consolidate_migration(&mut conn, None).unwrap();
913        assert_eq!(stats.instances_skipped_continuation, 1);
914        // Only inst-A and the template made it into db_agents.
915        assert_eq!(count_agents(&conn, "1 = 1"), 2);
916        assert_eq!(count_agents(&conn, "id = 'inst-cont'"), 0);
917    }
918
919    #[test]
920    fn empty_database_is_clean_noop() {
921        let mut conn = fresh_conn();
922        let stats = run_consolidate_migration(&mut conn, None).unwrap();
923        assert_eq!(stats, ConsolidateStats::default());
924        assert_eq!(count_agents(&conn, "1 = 1"), 0);
925    }
926
927    #[test]
928    fn preserves_hidden_flag_into_user_hidden() {
929        let mut conn = fresh_conn();
930        insert_def(&conn, "tpl-1", "Coder", 1, "");
931        insert_instance(&conn, "inst-H", "tpl-1", "Hidden", "", "", "/wd/h", 100, true);
932        run_consolidate_migration(&mut conn, None).unwrap();
933        let hidden: i64 = conn
934            .query_row(
935                "SELECT user_hidden FROM db_agents WHERE id = 'inst-H'",
936                [],
937                |r| r.get(0),
938            )
939            .unwrap();
940        assert_eq!(hidden, 1);
941    }
942}